5.8. 高级流水线指令注意事项

回想一下,流水线通过重叠执行多个指令来提高处理器的性能。在我们关于流水线的早期讨论中,我们描述了一个简单的四阶段流水线,其基本阶段为获取 (F)、解码 (D)、执行 (E) 和写回 (W)。在接下来的讨论中,我们还将考虑第五阶段,即内存 (M),它代表对数据内存的访问。因此,我们的五阶段流水线包括以下阶段:

  • 获取(F): 从内存中读取一条指令(由程序计数器指向)。
  • 解码(D): 读取源寄存器并设置控制逻辑。
  • 执行(E): 执行指令。
  • 内存(M): 读取或写入数据内存。
  • WriteBack (W): 将结果存储在目标寄存器中。

回想一下,编译器将代码行转换为一系列机器代码指令,供 CPU 执行。汇编代码是机器代码的人类可读版本。下面的代码片段显示了一系列虚构的汇编指令:

MOV M[0x84], Reg1     # move value at memory address 0x84 to register Reg1
ADD 2, Reg1, Reg1     # add 2 to value in Reg1 and store result in Reg1
MOV 4, Reg2           # copy the value 4 to register Reg2
ADD Reg2, Reg2, Reg2  # compute Reg2 + Reg2, store result in Reg2
JMP L1<0x14>          # jump to executing code at L1 (code address 0x14)

如果您在解析代码片段时遇到问题,请不要担心——我们将在即将推出的章节中更详细地介绍汇编。现在,只需关注以下事实:

  • 每个 ISA 都定义了一组指令。
  • 每条指令对一个或多个操作数(即寄存器,内存或常数值)进行操作。
  • 并非所有指令都需要相同数量的流水线阶段来执行。

在我们之前的讨论中,我们假设每条指令的执行时间相同;然而,通常情况并非如此。例如,第一个 MOV 指令需要所有五个阶段,因为它需要将数据从内存移动到寄存器。相反,由于操作只涉及寄存器而不涉及内存,因此接下来的三条指令只需要四个阶段(F、D、E、W)即可执行。最后一条指令(“JMP”)是一种分支或条件指令。其目的是将控制流转移到代码的另一部分。具体而言,内存代码区域中的地址引用可执行文件中的不同指令。由于 JMP 指令不更新通用寄存器,因此省略了 WriteBack 阶段,从而只需要三个阶段(F、D、E)。我们将在 即将推出的章节 中更详细地介绍条件指令。

当任何指令被迫等待另一条指令执行完毕后才能继续执行时,就会发生流水线停顿。编译器和处理器会尽一切可能避免流水线停顿,以最大限度地提高性能。

5.8.1. 流水线考虑:数据风险

当两条指令尝试访问指令管道中的公共数据时,就会发生数据危险。例如​​,考虑上面代码片段中的第一对指令:

MOV M[0x84], Reg1    # move value at memory address 0x84 to register Reg1
ADD 2, Reg1, Reg1    # add 2 to value in Reg1 and store result in Reg1

data hazard

图 1. 两条指令同时到达同一流水线阶段而产生流水线危险的示例。

回想一下,此MOV指令需要五个阶段(因为它涉及访问内存),而ADD指令只需要四个阶段。在这种情况下,两个指令都将尝试同时写入寄存器Reg1(参见图 1)。

处理器通过首先强制每条指令采用五个流水线阶段来执行来防止上述情况的发生。对于通常需要少于五个阶段的指令,CPU 会添加一条“无操作”(NOP)指令(也称为流水线“气泡”)来替代该阶段。

然而问题仍然没有完全解决。由于第二条指令的目标是将2添加到寄存器Reg1中存储的值,因此MOV指令需要先完成对寄存器Reg1的写入,然后ADD指令才能正确执行。接下来的两条指令也存在类似的问题:

MOV 4, Reg2           # copy the value 4 to register Reg2
ADD Reg2, Reg2, Reg2  # compute Reg2 + Reg2, store result in Reg2

data hazard 2

图 2. 处理器可以通过在指令之间转发操作数来减少流水线危险造成的损害。

这两条指令将值4加载到寄存器Reg2中,然后将其乘以 2(通过将其自身相加)。再次添加气泡以强制每条指令需要五个流水线阶段。在这种情况下,无论气泡如何,第二条指令的执行阶段都发生在第一条指令完成将所需值(4)写入寄存器Reg2之前。

添加更多气泡并不是一个最优解决方案,因为它会停滞流水线。相反,处理器采用了一种称为操作数转发的技术,其中流水线读取前一个操作的结果。查看图 2,在指令MOV 4, Reg2执行时,它将其结果转发给指令ADD Reg2, Reg2, Reg2。因此,当MOV指令写入寄存器Reg2时,ADD指令可以使用从MOV指令收到的Reg2的更新值。

5.8.2. 流水线危险:控制危险

流水线针对连续发生的指令进行了优化。程序中由诸如if语句或循环之类的条件引起的控制变化会严重影响流水线性能。让我们看一个不同的示例代码片段,首先是 C 语言代码:

int result = *x; // x holds an int
int temp = *y;   // y holds another int

if (result <= temp) {
	result = result - temp;
}
else {
	result = result + temp;
}
return result;

此代码片段只是从两个不同的指针读取整数数据,比较值,然后根据结果执行不同的算术。以下是上述代码片段如何转换为汇编指令:


  MOV M[0x84], Reg1     # move value at memory address 0x84 to register Reg1
  MOV M[0x88], Reg2     # move value at memory address 0x88 to register Reg2
  CMP Reg1, Reg2        # compare value in Reg1 to value in Reg2
  JLE L1<0x14>          # switch code execution to L1 if Reg1 less than Reg2
  ADD Reg1, Reg2, Reg1  # compute Reg1 + Reg2, store result in Reg1
  JMP L2<0x20>          # switch code execution to L2 (code address 0x20)
L1:
  SUB Reg1, Reg2, Reg1  # compute Reg1 - Reg2, store in Reg1
L2:
  RET                   # return from function

该指令序列将数据从内存加载到两个独立的寄存器中,比较这两个值,然后根据第一个寄存器中的值是否小于第二个寄存器中的值执行不同的算术运算。在上面的例子中,if 语句用两个指令表示:比较(CMP)指令和条件跳转小于(JLE)指令。我们将在即将推出的汇编 章节中更详细地介绍条件指令;现在,只需理解 CMP 指令比较两个寄存器,而 JLE 指令是一种特殊类型的分支指令,当且仅当条件(在本例中为小于或等于)为真时,才将代码执行切换到程序的另一部分。

不要被细节所困扰!

第一次看汇编语言可能会让人感到害怕,这是可以理解的。如果您有这种感觉,请不要担心!我们将在接下来的章节中更详细地介绍汇编语言。关键点是,包含条件语句的代码会像任何其他代码片段一样转换为一系列汇编指令。但是,与其他代码片段不同,条件语句不能保证以特定方式执行。围绕条件语句如何执行的不确定性对管道有很大的影响。

conditional hazard 1

图 3. 条件分支导致的控制危险的示例。

当流水线遇到分支(或条件)指令时,就会发生控制危险。发生这种情况时,流水线必须“猜测”是否会执行分支。如果不执行分支,则进程继续按顺序执行下一个指令。考虑图 3 中的示例。如果执行了分支,则执行的下一个指令应该是 SUB 指令。但是,在 JLE 指令完成执行之前,不可能知道是否执行了分支。此时,ADDJMP 指令已经加载到流水线中。如果执行了分支,则需要删除或刷新流水线中的这些“垃圾”指令,然后才能用新指令重新加载流水线。刷新流水线的代价很高。

硬件工程师可以选择实施一些选项来帮助处理器处理控制危险:

  • 停止流水线:作为一个简单的解决方案,每当有分支时,添加大量的 NOP  气泡并停止流水线,直到处理器确定该分支被采用。虽然停止流水线可以解决问题,但也会导致性能下降(参见 图 4)。
  • 分支预测:最常见的解决方案是使用分支预测器,它会根据之前的执行情况预测分支的走向。现代分支预测器确实非常出色且准确。然而,这种方法最近导致了一些安全漏洞(例如 Spectre1)。图 4 描述了分支预测器如何处理讨论的控制危险。
  • 立即执行:在立即执行中,CPU 执行分支的两侧并执行有条件的数据传输而不是控制(分别通过 x86 和 ARMv8-A 中的 cmov 和 csel 指令实现)。有条件的数据传输使处理器能够继续执行而不会中断管道。但是,并非所有代码都能够利用立即执行,这在指针取消引用和副作用的情况下可能会很危险。

conditional hazard 2

图 4.处理控制危害的潜在解决方案。

引用

  1. Peter Bright. Google: Software is never going to be able to fix Spectre-type bugs Ars Technica 2019.